NextBlog

Bridge 계층을 추상화하자

coverImg

배경

최근에 웹뷰 앱을 만들게 되면서 앱과 웹 간 통신을 구현해야하는 일들이 있었습니다. 하지만 웹과 앱 통신은 window.ReactNativeWebView 라는 객체를 통신하는데 다음과 같은 문제점들이 있었습니다.
 

기존 통신 방식의 문제점

1. 반복적이고 명시적인 코드
window.ReactNativeWebView.postMessage( JSON.stringify({ type: MESSAGE_TYPES.OPEN_EXTERNAL_BROWSER, url, }) );
2. 단일 채널에서의 모든 이벤트 처리
const handleWebViewMessage = useCallback( (event: MessageEvent) => { const { type, data } = JSON.parse(event.data); if (type === MESSAGE_TYPES.AUTH_SUCCESS) { handleOAuthCallback() } else if (type === MESSAGE_TYPES.AUTH_ERROR) { ... } }, [handleOAuthCallback] ); window.addEventListener('message', handleWebViewMessage)
 

기존 방식의 한계

이는 다음과 같은 문제들이 있었습니다.
명시적이고 반복적인 코드
타입 추론의 어려움
 

설계 목표

그래서 이러한 문제들을 해결하기 위해 다음과 같은 목표를 가진 bridge 패키지를 만들고자 하였습니다.
핵심은 추상화를 통한 DX 개선입니다. 복잡한 통신 로직을 숨기고 간단한 API를 제공하여 팀 전체의 개발 효율성을 높이는 것이 주요 목표였습니다.

아키텍쳐 아이디어

이때 이벤트 처리 메커니즘은 OS의 ISR Vector Table에 영감을 받았습니다
notion image
동작원리는 다음과 같습니다.
 
이와 비슷하게 저희 bridge 패키지도 다음과 같은 방식으로 만들게 되었습니다.
많은 내용을 넣고싶다보니 글자가 잘 안보이네요…. 클릭해서 확대할 수 있습니다!
많은 내용을 넣고싶다보니 글자가 잘 안보이네요…. 클릭해서 확대할 수 있습니다!
  1. addEventListener로 이벤트이름과 콜백 함수를 전달
  1. WebBridge에서 이벤트를 globalEventListeners에 등록
    1. globalEventListener는 Vector Table 역할
  1. AppBridge의 send에서 이벤트이름에 해당하는곳에 payload전달
  1. 한 채널에서 수행하기 위한 globalMessageHandler에서 이벤트 이름에 해당하는 콜백 함수 실행
 
이런 역할을 수행하기 위한 곳은 Bridge라는 한 인터페이스에서 수행됩니다. 이 인터페이스는 웹에 모든 컴포넌트에서 동일한 인스턴스를 사용해야하기 때문에 싱글톤 패턴을 선택하게 되었습니다.

실제 구현

실제 구현은 다음과 같이 되어있습니다.
 
1. 싱글톤 패턴 구현
export const createWebBridge = (() => { let bridgeInstance: BridgeInstance | null = null let isGlobalListenerRegistered = false // 이벤트 리스너들을 저장할 Map (Vector Table 역할) const globalEventListeners = new Map< string, (( event: PostMessageSchemaObject[keyof PostMessageSchemaObject]['payload'], ) => void)[] >() return () => { if (bridgeInstance) { return bridgeInstance // 기존 인스턴스 반환 } bridgeInstance = createBridgeInstance() // 없으면 새 인스턴스 생성 return bridgeInstance } })()
2. 전역 메시지 핸들러
const globalMessageHandler = (event: MessageEvent) => { const message = handleMessage(event.data) if (message) { // Vector Table에서 해당 이벤트의 핸들러들 찾기 const listeners = globalEventListeners.get(message.eventName) if (listeners) { // 해당하는 모든 핸들러 실행 listeners.forEach((listener) => listener(message.payload)) } } }
3. API 타입 안정성
const send = <K extends keyof T>( eventName: K, payload: T[K]['payload'], ): void => { const message: PostMessageEvent<T> = { eventName, payload } window.ReactNativeWebView!.postMessage(JSON.stringify(message)) } // 콜백 함수의 매개변수 타입이 자동 추론됨 const addEventListener = <K extends keyof T>( eventName: K, callback: (payload: T[K]['payload']) => void, ) => { ...
 

사용 예시

1. addEventListener
export const useBridgeEvent = <T extends keyof BridgeMessageSchema>( eventType: T, callback: (payload: BridgeMessageSchema[T]['payload']) => void, deps: DependencyList = [], ) => { const { bridge } = useBridge() useEffect(() => { const unsubscribe = bridge.addEventListener(eventType, callback) return () => unsubscribe() }, [eventType, callback, ...deps]) }
// 로그아웃 브릿지 이벤트 핸들러 useBridgeEvent( POST_MESSAGE_EVENT.IMAGE_SELECTED, (payload) => { // payload를 ImageData 타입으로 자동 추론 const newImages = data.imageDataList .filter( (imageData: BridgeImageData) => imageData.base64 && imageData.createdAt && imageData.fileName, ) .map((imageData: BridgeImageData) => ({ fileName: imageData.fileName, )
2. send
bridge.send('OPEN_CAMERA', { message: 'Open camera' })
 

Before/After 비교

1. 이벤트 리스너 (Before/After)
// Before: 기존 방식 const handleMessage = useCallback((event: MessageEvent) => { const { type, data } = JSON.parse(event.data); // 타입 추론 안됨, if-else 체인 필요 if (type === 'AUTH_SUCCESS') { handleAuth(data); // data 타입 unknown } else if (type === 'CAMERA_RESULT') { handleCamera(data); // data 타입 unknown } }, []); // After: Bridge 사용 useBridgeEvent('CAMERA_SUCCESS', (payload) => { // payload 타입 자동 추론, 안전함 console.log(payload.imageList) // 자동완성 지원 })
2. 메시지 전송 (Before/After)
// Before: 매번 직접 JSON.stringify + 타입 정보 없음 window.ReactNativeWebView.postMessage( JSON.stringify({ type: 'CAMERA_OPEN', data: { quality: 0.8, allowsEditing: true } }) ); // 타입 실수 가능 window.ReactNativeWebView.postMessage( JSON.stringify({ type: 'CAMREA_OPEN', // 오타 발생 가능 data: { quality: "high" } // 잘못된 타입 }) ); // After: 타입 안전 + 간결함 bridge.send('CAMERA_OPEN', { quality: 0.8, allowsEditing: true }); // 컴파일 시점에 오류 발견 bridge.send('CAMREA_OPEN', payload); // 타입 에러 bridge.send('CAMERA_OPEN', { quality: "high" }); // 타입 에러
 

개선할 점과 회고

처음 개발부터 완벽했던것은 아니였습니다. 개발 과정에서 발견한 몇 가지 아쉬운 점들이 있습니다
 

결론

처음에는 단순히 명시적이던 코드들을 추상화하여 개선하려던 작업이었는데, 결과적으로는 팀 전체의 WebView 통신 패턴을 표준화할 수 있었습니다. 이제 복잡한 코드 없이 bridge.send()와 useBridgeEvent()만 알면 누구든 바로 WebView 통신을 구현할 수 있습니다.
 
이 bridge 패키지를 통해 기존 대비 약 70% 코드 감소, 약 2000줄 이상 절약을 할 수 있었고, 런타임에서 타입 에러를 통해 안정적인 통신을 가능하게 하였습니다.
 
물론 아직 개선할 점들이 많습니다. 특히 웹과 앱 간 타입 공유 부분은 더 우아한 방법이 있을 것 같고, WeakMap이나 Symbol 활용도 고민해볼 만합니다.
 
사실 구현 후에는 "동시성은 어떻게 처리하지?", "Symbol이랑 같이 WeakMap을 써서 메모리 관리를 더 효율적으로 할까?", " 같은 생각들이 많았어요. 하지만 YAGNI(You Aren't Gonna Need It)를 떠올리며 일단 가장 단순한 버전으로 시작했습니다.
 
결과적으로 Map과 문자열 키를 사용하는 현재 구조만으로도 팀의 모든 요구사항을 충분히 만족시킬 수 있었습니다.
 
이번 브릿지 패키지를 구현하면서 DX 개선이 생각보다 까다롭다는 걸 느꼈지만, 동시에 복잡했던 WebView 통신 코드들이 간결해지는 모습을 보며 큰 재미를 느낄 수 있었습니다. 특히 정확한 타입 추론을 위해 제네릭을 활용하면서 TypeScript의 매력을 다시 한번 체감할 수 있었습니다.